/**
 * Copyright (C) 2016 - 2020 Order of the Bee
 *
 * This file is part of OOTBee Support Tools
 *
 * OOTBee Support Tools is free software: you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation, either version 3 of the License,
 * or (at your option) any later version.
 *
 * OOTBee Support Tools is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
 * General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with OOTBee Support Tools. If not, see
 * <http://www.gnu.org/licenses/>.
 *
 * Linked to Alfresco
 * Copyright (C) 2005 - 2020 Alfresco Software Limited.
 */
package org.orderofthebee.addons.support.tools.repo.config;

import java.io.IOException;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.repo.management.subsystems.AbstractPropertyBackedBean;
import org.alfresco.repo.management.subsystems.PropertyBackedBean;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanRegisteredEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanRegistry;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanRemovePropertiesEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanSetPropertiesEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanSetPropertyEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanStartedEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanStoppedEvent;
import org.alfresco.repo.management.subsystems.PropertyBackedBeanUnregisteredEvent;
import org.alfresco.service.cmr.attributes.AttributeService;
import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.service.descriptor.Descriptor;
import org.alfresco.service.descriptor.DescriptorService;
import org.alfresco.service.transaction.TransactionService;
import org.alfresco.util.ParameterCheck;
import org.alfresco.util.PropertyCheck;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;

/**
 * An instance of this class processes events generated by a {@link PropertyBackedBeanRegistry} to handle state changes of
 * {@link PropertyBackedBean} instances and load/persist their configuration via the {@link AttributeService}.
 *
 * @author Axel Faust
 */
public class PropertyBackedBeanPersister implements InitializingBean
{

    private static final String EDITION_ENTERPRISE = "Enterprise";

    private static final Logger LOGGER = LoggerFactory.getLogger(PropertyBackedBeanPersister.class);

    private static final String LEGACY_ROOT_PATH = ".PropertyBackedBeans";

    // since AttributeService only supports up to three keys we need to munge this, so 2nd key can be bean name and 3rd key the actual
    // property
    private static final String ROOT_PATH = "ootbee-support-tools.property-backed-beans";

    protected PropertyBackedBeanRegistry registry;

    protected DescriptorService descriptorService;

    protected TransactionService transactionService;

    protected AttributeService attributeService;

    protected SimpleCache<String, Map<String, String>> propertyBackedBeanPropertiesCache;

    protected boolean enabled;

    // using legacy JMX keys for read access may be a tool for supporting Enterprise => Community migrations
    protected boolean useLegacyJmxKeysForRead;

    // if legacy JMX keys are used for read, they may need to be processed when config needs to be cleared / removed, a property backed bean
    // could not revert back to its default state
    protected boolean processLegacyJmxKeysOnRemoveProperties;

    protected final Set<PropertyBackedBeanHolder> knownPropertyBackedBeanInstances = new CopyOnWriteArraySet<>();

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public void afterPropertiesSet()
    {
        PropertyCheck.mandatory(this, "registry", this.registry);
        PropertyCheck.mandatory(this, "descriptorService", this.descriptorService);

        if (this.enabled)
        {
            final Descriptor serverDescriptor = this.descriptorService.getServerDescriptor();
            final String edition = serverDescriptor.getEdition();
            if (!EDITION_ENTERPRISE.equals(edition))
            {
                PropertyCheck.mandatory(this, "transactionService", this.transactionService);
                PropertyCheck.mandatory(this, "attributeService", this.attributeService);
                PropertyCheck.mandatory(this, "propertyBackedBeanPropertiesCache", this.propertyBackedBeanPropertiesCache);

                // we do not implement ApplicationListener interface to avoid being handled specially by Spring just because Alfresco
                // PropertyBackedBeanRegistry requires this interface
                // using Java 8 lambda + method handles we can still register ourselves
                this.registry.addListener(this::onApplicationEvent);
                LOGGER.info("OOTBee Support Tools - PropertyBackedBeanPersister enabled");
            }
            else
            {
                LOGGER.info(
                        "OOTBee Support Tools - PropertyBackedBeanPersister may conflict with JMX support in Enterprise Edition and will not be enabled");
            }
        }
        else
        {
            LOGGER.info("OOTBee Support Tools - PropertyBackedBeanPersister has not been enabled");
        }
    }

    /**
     * @param registry
     *            the registry to set
     */
    public void setRegistry(final PropertyBackedBeanRegistry registry)
    {
        this.registry = registry;
    }

    /**
     * @param descriptorService
     *            the descriptorService to set
     */
    public void setDescriptorService(final DescriptorService descriptorService)
    {
        this.descriptorService = descriptorService;
    }

    /**
     * @param transactionService
     *            the transactionService to set
     */
    public void setTransactionService(final TransactionService transactionService)
    {
        this.transactionService = transactionService;
    }

    /**
     * @param attributeService
     *            the attributeService to set
     */
    public void setAttributeService(final AttributeService attributeService)
    {
        this.attributeService = attributeService;
    }

    /**
     * @param propertyBackedBeanPropertiesCache
     *            the propertyBackedBeanPropertiesCache to set
     */
    public void setPropertyBackedBeanPropertiesCache(final SimpleCache<String, Map<String, String>> propertyBackedBeanPropertiesCache)
    {
        this.propertyBackedBeanPropertiesCache = propertyBackedBeanPropertiesCache;
    }

    /**
     * @param enabled
     *            the enabled to set
     */
    public void setEnabled(final boolean enabled)
    {
        this.enabled = enabled;
    }

    /**
     * @param useLegacyJmxKeysForRead
     *            the useLegacyJmxKeysForRead to set
     */
    public void setUseLegacyJmxKeysForRead(final boolean useLegacyJmxKeysForRead)
    {
        this.useLegacyJmxKeysForRead = useLegacyJmxKeysForRead;
    }

    /**
     * @param processLegacyJmxKeysOnRemoveProperties
     *            the processLegacyJmxKeysOnRemoveProperties to set
     */
    public void setProcessLegacyJmxKeysOnRemoveProperties(final boolean processLegacyJmxKeysOnRemoveProperties)
    {
        this.processLegacyJmxKeysOnRemoveProperties = processLegacyJmxKeysOnRemoveProperties;
    }

    /**
     * Handle an application event.
     *
     * @param applicationEvent
     *            the event to which to respond
     *
     * @see ApplicationListener#onApplicationEvent(ApplicationEvent)
     */
    protected void onApplicationEvent(final ApplicationEvent applicationEvent)
    {
        if (applicationEvent instanceof PropertyBackedBeanEvent)
        {
            final PropertyBackedBean source = (PropertyBackedBean) applicationEvent.getSource();
            LOGGER.debug("Property backed bean {} triggered event {}", source.getId(), applicationEvent);

            if (applicationEvent instanceof PropertyBackedBeanRegisteredEvent)
            {
                this.handleNewPropertyBackedBean(source);
            }
            else if (applicationEvent instanceof PropertyBackedBeanUnregisteredEvent)
            {
                this.handleRemovedPropertyBackedBean(source, ((PropertyBackedBeanUnregisteredEvent) applicationEvent).isPermanent());
            }
            else if (applicationEvent instanceof PropertyBackedBeanSetPropertiesEvent)
            {
                this.setProperties(source, ((PropertyBackedBeanSetPropertiesEvent) applicationEvent).getProperties());
            }
            else if (applicationEvent instanceof PropertyBackedBeanSetPropertyEvent)
            {
                this.setProperty(source, ((PropertyBackedBeanSetPropertyEvent) applicationEvent).getName(),
                        ((PropertyBackedBeanSetPropertyEvent) applicationEvent).getValue());
            }
            else if (!(applicationEvent instanceof PropertyBackedBeanStartedEvent
                    || applicationEvent instanceof PropertyBackedBeanStoppedEvent
                    || applicationEvent instanceof PropertyBackedBeanRemovePropertiesEvent))
            {
                LOGGER.warn("Received unsupported / unexpected application event: {}", applicationEvent);
            }
            // else we can ignore these
        }
        else
        {
            LOGGER.warn("Received unsupported / unexpected application event: {}", applicationEvent);
        }
    }

    protected void handleNewPropertyBackedBean(final PropertyBackedBean propertyBackedBean)
    {
        final StringBuilder nameBuilder = new StringBuilder(256);
        propertyBackedBean.getId().forEach(id -> {
            if (nameBuilder.length() > 0)
            {
                nameBuilder.append('$');
            }
            try
            {
                // similar encoding as Enterprise uses (at least judging from DB entries written by AttributeService)
                nameBuilder.append(URLEncoder.encode(id, StandardCharsets.UTF_8.name()));
            }
            catch (final IOException ioex)
            {
                LOGGER.error("Unexpected IO exception encoding an ID fragment of a property backed bean", ioex);
                throw new AlfrescoRuntimeException("Unexpected IO exception encoding ID fragment", ioex);
            }
        });
        final String name = nameBuilder.toString();
        this.knownPropertyBackedBeanInstances.add(new PropertyBackedBeanHolder(name, propertyBackedBean));

        // for some reason most subsystem beans are by default not configured to broadcast property changes
        // JMX only works because it is the only tool / component dealing with this, and only performs direct calls
        if (propertyBackedBean instanceof AbstractPropertyBackedBean)
        {
            ((AbstractPropertyBackedBean) propertyBackedBean).setSaveSetProperty(true);
        }

        this.initializeFromPersistedProperties(name, propertyBackedBean);
    }

    protected void handleRemovedPropertyBackedBean(final PropertyBackedBean propertyBackedBean, final boolean permanent)
    {
        final String name = this.lookupPropertyBackedBeanName(propertyBackedBean);

        if (permanent)
        {
            this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> {
                this.clearPropertiesInTransaction(name);
                return null;
            }, false, true);
        }

        this.knownPropertyBackedBeanInstances.remove(new PropertyBackedBeanHolder(propertyBackedBean));
    }

    protected void initializeFromPersistedProperties(final String name, final PropertyBackedBean propertyBackedBean)
    {
        final Map<String, String> properties = this.getPersistedProperties(name);
        properties.keySet().removeIf(key -> !propertyBackedBean.isUpdateable(key));
        if (!properties.isEmpty())
        {
            try
            {
                propertyBackedBean.setProperties(properties);
                LOGGER.debug("Initialised {} from persisted properties {}", name, properties);
            }
            catch (AlfrescoRuntimeException are)
            {
                LOGGER.warn("Error initialising {} from persisted properties {}: {}", name, properties, are.getMessage());
                // Setting properties will initialise subsystems even if they are not enabled
                // Rethrowing would interrupt Alfresco startup even if affected subsystem is actually disabled
                // This is a design / implementation flaw in Alfresco subsystems (should also affect JMX)
                // If subsystem is actually enabled, same error should occur when it is properly started
                // see https://github.com/OrderOfTheBee/ootbee-support-tools/issues/132#issuecomment-458252681
            }
        }
        else
        {
            LOGGER.debug("No persisted properties exist for bean {}", name);
        }
    }

    protected Map<String, String> getPersistedProperties(final String name)
    {
        final Map<String, String> persistedProperties = this.transactionService.getRetryingTransactionHelper()
                .doInTransaction(() -> this.getPersistedPropertiesInTransaction(name), true, false);
        return persistedProperties;
    }

    protected void setProperty(final PropertyBackedBean propertyBackedBean, final String propertyKey, final String propertyValue)
    {
        ParameterCheck.mandatory("propertyBackedBean", propertyBackedBean);
        ParameterCheck.mandatoryString("propertyKey", propertyKey);
        // propertyValue is allowed to be an empty string to override some config value default
        ParameterCheck.mandatory("propertyKey", propertyKey);

        final String name = this.lookupPropertyBackedBeanName(propertyBackedBean);
        this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> {
            this.setPropertyInTransaction(name, propertyKey, propertyValue);
            return null;
        }, false, true);
    }

    protected void setProperties(final PropertyBackedBean propertyBackedBean, final Map<String, String> properties)
    {
        ParameterCheck.mandatory("propertyBackedBean", propertyBackedBean);
        ParameterCheck.mandatory("properties", properties);

        final String name = this.lookupPropertyBackedBeanName(propertyBackedBean);
        if (!properties.isEmpty())
        {
            this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> {
                this.setPropertiesInTransaction(name, properties);
                return null;
            }, false, true);
        }
    }

    protected void removeProperties(final PropertyBackedBean propertyBackedBean, final Collection<String> propertyKeys)
    {
        ParameterCheck.mandatory("propertyBackedBean", propertyBackedBean);
        ParameterCheck.mandatory("propertyKeys", propertyKeys);

        final String name = this.lookupPropertyBackedBeanName(propertyBackedBean);
        if (!propertyKeys.isEmpty())
        {
            this.removePropertiesInTransaction(name, propertyKeys);
        }
    }

    protected Map<String, String> getPersistedPropertiesInTransaction(final String name)
    {
        Map<String, String> persistedProperties = this.propertyBackedBeanPropertiesCache.get(name);
        if (persistedProperties == null)
        {
            persistedProperties = this.loadPersistedProperties(name);
        }

        // decouple
        persistedProperties = new HashMap<>(persistedProperties);

        return persistedProperties;
    }

    protected void setPropertyInTransaction(final String name, final String propertyKey, final String propertyValue)
    {
        this.attributeService.setAttribute(propertyValue, ROOT_PATH, name, propertyKey);
        if (LOGGER.isDebugEnabled())
        {
            final String lowerCasedKey = propertyKey.toLowerCase(Locale.ENGLISH);
            final boolean sensitiveKey = lowerCasedKey.contains("password") || lowerCasedKey.contains("api.key");
            LOGGER.debug("Set property {} for {} to {}", propertyKey, name, sensitiveKey ? "*****" : propertyValue);
        }
        this.propertyBackedBeanPropertiesCache.remove(name);
    }

    protected void setPropertiesInTransaction(final String name, final Map<String, String> properties)
    {
        properties.forEach((propertyKey, propertyValue) -> this.attributeService.setAttribute(propertyValue, ROOT_PATH, name, propertyKey));
        LOGGER.debug("Set properties for {}: {}", name, properties);
        this.propertyBackedBeanPropertiesCache.remove(name);
    }

    protected void removePropertiesInTransaction(final String name, final Collection<String> propertyKeys)
    {
        propertyKeys.forEach((propertyKey) -> this.attributeService.removeAttribute(ROOT_PATH, name, propertyKey));

        if (this.processLegacyJmxKeysOnRemoveProperties)
        {
            @SuppressWarnings("unchecked")
            final Map<String, String> legacyProperties = (Map<String, String>) this.attributeService.getAttribute(LEGACY_ROOT_PATH, name);
            if (legacyProperties != null)
            {
                final boolean changed = legacyProperties.keySet().removeAll(propertyKeys);
                if (changed)
                {
                    this.attributeService.setAttribute((Serializable) legacyProperties, LEGACY_ROOT_PATH, name);
                    LOGGER.debug("Removal of property keys {} for {} also resulted in removal of legacy JMX properties", propertyKeys,
                            name);
                }
            }
        }

        this.propertyBackedBeanPropertiesCache.remove(name);
    }

    protected void clearPropertiesInTransaction(final String name)
    {
        if (this.processLegacyJmxKeysOnRemoveProperties)
        {
            this.attributeService.removeAttribute(LEGACY_ROOT_PATH, name);
        }
        this.attributeService.removeAttributes(ROOT_PATH, name);
        LOGGER.debug("Cleared properties for {}", name);
        this.propertyBackedBeanPropertiesCache.remove(name);
    }

    protected Map<String, String> loadPersistedProperties(final String name)
    {
        final Map<String, String> persistedProperties = new HashMap<>();

        if (this.useLegacyJmxKeysForRead)
        {
            @SuppressWarnings("unchecked")
            final Map<String, String> legacyProperties = (Map<String, String>) this.attributeService.getAttribute(LEGACY_ROOT_PATH, name);
            if (legacyProperties != null)
            {
                persistedProperties.putAll(legacyProperties);
            }
        }

        this.attributeService.getAttributes((id, value, keys) -> {
            final String propertyKey = DefaultTypeConverter.INSTANCE.convert(String.class, keys[2]);
            final String propertyValue = DefaultTypeConverter.INSTANCE.convert(String.class, value);

            persistedProperties.put(propertyKey, propertyValue);

            return true;
        }, ROOT_PATH, name);

        LOGGER.debug("Loaded persisted properties for key {}: {}", name, persistedProperties);
        this.propertyBackedBeanPropertiesCache.put(name, persistedProperties);
        return persistedProperties;
    }

    protected String lookupPropertyBackedBeanName(final PropertyBackedBean propertyBackedBean)
    {
        final Optional<PropertyBackedBeanHolder> match = this.knownPropertyBackedBeanInstances.stream()
                .filter(beanHolder -> beanHolder.getPropertyBackedBean() == propertyBackedBean).findFirst();

        final String name = match
                .orElseThrow(() -> new AlfrescoRuntimeException("PropertyBackedBean has not properly registered itself to be supported"))
                .getName();
        return name;
    }
}
